SPB Git

Repository Public

page.tsx 11.9 KB 288 lines tsx

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin/demande/[ref] — la vue complète d'une demande : identité, contenu,3// métadonnées du formulaire, pièces jointes, workflow, notes internes,4// conversation et historique d'activité. L'ouverture marque la demande lue.5// Contrôle d'accès PAR demande : un admin sans droit reçoit un 404.6import { notFound, redirect } from "next/navigation";7import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";8import {9  findSubmissionByRef,10  attachmentsOf,11  notesOf,12  messagesOf,13  eventsOf,14  listAdmins,15  updateSubmission,16} from "@/lib/comms/db";17import {18  categoryBySlug,19  statusesFor,20  PRIORITIES,21  CONFIDENTIALITY_LEVELS,22} from "@/lib/comms/categories";23import SubmissionActions from "./SubmissionActions";2425export const dynamic = "force-dynamic";2627function fmtDate(iso: string): string {28  return iso.replace("T", " ").slice(0, 16);29}3031function fmtBytes(n: number): string {32  if (n < 1024) return `${n} o`;33  if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;34  return `${(n / 1024 / 1024).toFixed(1)} Mo`;35}3637function Meta({ label, value }: { label: string; value: React.ReactNode }) {38  if (value == null || value === "") return null;39  return (40    <div className="min-w-0">41      <p className="klabel">{label}</p>42      <p className="mt-[2px] text-[13.5px] break-words">{value}</p>43    </div>44  );45}4647export default async function SubmissionPage({48  params,49}: {50  params: Promise<{ ref: string }>;51}) {52  const session = await getAdminSession();53  if (!session) redirect("/admin/connexion");54  const ref = decodeURIComponent((await params).ref).slice(0, 40);55  const sub = findSubmissionByRef(ref);56  if (!sub || !canViewSubmission(session, sub)) notFound();5758  if (!sub.is_read) updateSubmission(sub.id, { is_read: 1 });5960  const cat = categoryBySlug(sub.category);61  const atts = attachmentsOf(sub.id);62  const notes = notesOf(sub.id);63  const messages = messagesOf(sub.id);64  const events = eventsOf(sub.id);65  const admins = listAdmins().filter((a) => a.active);66  const metadata = (() => {67    try {68      return JSON.parse(sub.metadata ?? "{}") as Record<string, unknown>;69    } catch {70      return {};71    }72  })();73  const tags = (() => {74    try {75      const t = JSON.parse(sub.tags ?? "[]") as unknown;76      return Array.isArray(t) ? t.filter((x): x is string => typeof x === "string") : [];77    } catch {78      return [];79    }80  })();81  // libellés humains des clés metadata, depuis le registre82  const fieldLabel = (key: string): string =>83    cat?.fields.find((f) => f.name === key)?.label ?? key;84  const displayName = sub.is_anonymous85    ? "Anonyme"86    : [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—";8788  return (89    <>90      <nav aria-label="Fil d'Ariane">91        <a href="/admin/inbox" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green">92          ← Inbox93        </a>94      </nav>9596      <div className="mt-4 flex flex-wrap items-center gap-3">97        <h1 className="gk-mono text-[clamp(17px,2.5vw,22px)] font-bold tracking-[0.06em] text-green">98          {sub.reference}99        </h1>100        <span className="adm-chip">{cat?.title ?? sub.category}</span>101        <span className={`adm-chip adm-chip--${sub.priority}`}>102          {PRIORITIES[sub.priority as keyof typeof PRIORITIES] ?? sub.priority}103        </span>104        <span className="adm-chip adm-chip--ghost">105          {statusesFor(sub.category)[sub.status] ?? sub.status}106        </span>107        <span className="adm-chip adm-chip--ghost">108          Confidentialité : {CONFIDENTIALITY_LEVELS[sub.confidentiality] ?? sub.confidentiality}109        </span>110        {sub.is_spam ? <span className="adm-chip adm-chip--urgente">Spam</span> : null}111        {sub.is_archived ? <span className="adm-chip adm-chip--ghost">Archivée</span> : null}112        {tags.map((t) => (113          <span key={t} className="adm-chip adm-chip--lime">#{t}</span>114        ))}115        <span className="gk-mono ml-auto text-[11px] text-ink-3">116          Reçue le {fmtDate(sub.created_at)}117          {sub.resolved_at ? ` · résolue le ${fmtDate(sub.resolved_at)}` : ""}118        </span>119      </div>120121      <div className="mt-6 grid gap-6 xl:grid-cols-[1fr_360px]">122        {/* colonne principale : identité + contenu + conversation + historique */}123        <div className="flex min-w-0 flex-col gap-6">124          <section className="gk-card p-5 !shadow-none">125            <p className="kicker">Identité</p>126            <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">127              <Meta label="Nom" value={displayName} />128              <Meta129                label="Courriel"130                value={sub.email ? <a className="text-green underline underline-offset-2" href={`mailto:${sub.email}`}>{sub.email}</a> : sub.is_anonymous ? "— (anonyme)" : null}131              />132              <Meta label="Téléphone" value={sub.phone} />133              <Meta label="Organisation" value={sub.organization} />134              <Meta label="Rôle / poste" value={sub.job_title} />135              <Meta136                label="KA ID"137                value={sub.ka_id ? <span className="gk-mono font-bold">{sub.ka_id}</span> : null}138              />139              <Meta140                label="Site web"141                value={sub.website ? <a className="text-green underline underline-offset-2" href={sub.website} target="_blank" rel="noopener noreferrer">{sub.website}</a> : null}142              />143              <Meta144                label="LinkedIn"145                value={sub.linkedin_url ? <a className="text-green underline underline-offset-2" href={sub.linkedin_url} target="_blank" rel="noopener noreferrer">{sub.linkedin_url}</a> : null}146              />147              <Meta148                label="GitHub / portfolio"149                value={sub.github_url ? <a className="text-green underline underline-offset-2" href={sub.github_url} target="_blank" rel="noopener noreferrer">{sub.github_url}</a> : null}150              />151            </div>152          </section>153154          <section className="gk-card p-5 !shadow-none">155            <p className="kicker">Contenu de la demande</p>156            <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">157              <Meta label="Sous-catégorie" value={sub.sub_category} />158              <Meta label="Site concerné" value={sub.site_concerned} />159              <Meta label="Sujet" value={sub.subject} />160              <Meta label="Source" value={sub.source} />161            </div>162            {sub.message ? (163              <div className="mt-5 border-l-2 border-green bg-surface-2 px-4 py-3">164                <p className="text-[14px] leading-relaxed whitespace-pre-wrap">{sub.message}</p>165              </div>166            ) : null}167            {Object.keys(metadata).length ? (168              <div className="mt-5">169                <p className="klabel">Métadonnées du formulaire</p>170                <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">171                  {Object.entries(metadata).map(([k, v]) => (172                    <Meta173                      key={k}174                      label={fieldLabel(k)}175                      value={v === true ? "Oui" : String(v)}176                    />177                  ))}178                </div>179              </div>180            ) : null}181            {atts.length ? (182              <div className="mt-5">183                <p className="klabel">Pièces jointes</p>184                <ul className="mt-2 flex flex-wrap gap-2">185                  {atts.map((a) => (186                    <li key={a.id}>187                      <a188                        href={`/api/admin/attachments/${a.id}`}189                        className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft"190                      >191                        ⭳ {a.original_name}192                        <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span>193                      </a>194                    </li>195                  ))}196                </ul>197              </div>198            ) : null}199          </section>200201          <section className="gk-card p-5 !shadow-none">202            <p className="kicker">Conversation ({messages.length})</p>203            {messages.length === 0 ? (204              <p className="gk-mono mt-3 text-[11.5px] text-ink-3">205                Aucun échange pour l&apos;instant — répondez depuis le panneau de droite.206              </p>207            ) : (208              <ul className="mt-4 flex flex-col gap-3">209                {messages.map((m) => (210                  <li211                    key={m.id}212                    className={`max-w-[85%] rounded-xl border-[1.5px] px-4 py-3 ${213                      m.sender_type === "admin"214                        ? "self-end border-ink bg-ink text-paper"215                        : "self-start border-line bg-surface-2"216                    }`}217                  >218                    <p className={`gk-mono text-[9.5px] font-bold tracking-[0.1em] uppercase ${m.sender_type === "admin" ? "text-lime" : "text-ink-3"}`}>219                      {m.sender_type === "admin" ? (m.author ?? "Équipe KA") : displayName} · {fmtDate(m.created_at)} · {m.channel}220                    </p>221                    <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{m.body}</p>222                  </li>223                ))}224              </ul>225            )}226          </section>227228          <section className="gk-card p-5 !shadow-none">229            <p className="kicker">Notes internes ({notes.length})</p>230            {notes.length === 0 ? (231              <p className="gk-mono mt-3 text-[11.5px] text-ink-3">Aucune note.</p>232            ) : (233              <ul className="mt-4 flex flex-col gap-3">234                {notes.map((n) => (235                  <li key={n.id} className="border-l-2 border-amber bg-amber-soft px-4 py-3">236                    <p className="gk-mono text-[9.5px] font-bold tracking-[0.1em] text-[#7a4d0d] uppercase">237                      {n.author} · {fmtDate(n.created_at)} · interne238                    </p>239                    <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{n.body}</p>240                  </li>241                ))}242              </ul>243            )}244          </section>245246          <section className="gk-card p-5 !shadow-none">247            <p className="kicker">Historique d&apos;activité</p>248            <ul className="mt-4 flex flex-col">249              {events.map((e) => (250                <li key={e.id} className="flex items-baseline gap-3 border-t border-line py-2 first:border-t-0">251                  <span className="gk-mono flex-none text-[10px] text-ink-3">{fmtDate(e.created_at)}</span>252                  <span className="adm-chip adm-chip--ghost flex-none !text-[9px]">{e.kind}</span>253                  <span className="min-w-0 text-[12.5px] text-ink-2">254                    {e.detail}255                    {e.author ? <span className="text-ink-3"> — {e.author}</span> : null}256                  </span>257                </li>258              ))}259            </ul>260          </section>261        </div>262263        {/* colonne workflow */}264        <aside className="h-fit xl:sticky xl:top-8">265          <div className="gk-card p-5">266            <p className="kicker">Workflow</p>267            <div className="mt-4">268              <SubmissionActions269                id={sub.id}270                statuses={statusesFor(sub.category)}271                status={sub.status}272                priority={sub.priority}273                confidentiality={sub.confidentiality}274                assignedTo={sub.assigned_to}275                tags={tags}276                admins={admins.map((a) => ({ id: a.id, name: a.display_name }))}277                isArchived={!!sub.is_archived}278                isSpam={!!sub.is_spam}279                hasEmail={!!sub.email}280              />281            </div>282          </div>283        </aside>284      </div>285    </>286  );287}288
Files main
Menu

Theme